构造函数与 new 命令
JavaScript 语言具有很强的面向对象编程能力。
对象是什么
构造函数
典型的面向对象编程语言(比如 C++ 和 Java),都有“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构造函数(constructor)和原型链(prototype)。
JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实例对象,这些实例对象都有相同的结构。
构造函数就是一个普通的函数,但是有自己的特征和用法。
为了与普通函数区别,构造函数名字的第一个字母通常大写。
构造函数的特点有两个。
new 命令
基本用法
new命令的作用,就是执行构造函数,返回一个实例对象。
使用new命令时,根据需要,构造函数也可以接受参数。
new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号。
如果忘了使用new命令,构造函数就变成了普通函数,并不会生成实例对象。this这时代表全局对象,将造成一些意想不到的结果。
因此,应该非常小心,避免不使用new命令、直接调用构造函数。
为了保证构造函数必须与new命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。
另一个解决办法,构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象。
不管加不加new命令,都会得到同样的结果。
new 命令的原理
使用new命令时,它后面的函数依次执行下面的步骤。
也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象(即this对象),将其“构造”为需要的样子。
如果构造函数内部有return语句,而且return后面跟着一个对象,new命令会返回return语句指定的对象;否则,就会不管return语句,返回this对象。这一点需要特别引起注意。
另一方面,如果对普通函数(内部没有this关键字的函数)使用new命令,则会返回一个空对象。
new.target
函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined。
使用这个属性,可以判断函数调用的时候,是否使用new命令。
Object.create() 创建实例对象
构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用Object.create()方法。
this 关键字
涵义
this都有一个共同点:它总是返回一个对象。
简单说,this就是属性或方法“当前”所在的对象。
由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即this的指向是可变的。
总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。
使用场合
全局环境
全局环境使用this,它指的就是顶层对象window。
不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window。
构造函数
构造函数中的this,指的是实例对象。
对象的方法
如果对象的方法里面包含this,this的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。
如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层。
上面代码中,a.b.m方法在a对象的第二层,该方法内部的this不是指向a,而是指向a.b。
使用注意点
避免多层 this
由于this的指向是不确定的,所以切勿在函数中包含多层的this。内层的this直接指向顶层对象
因为实际执行的是下面的代码。
使用一个变量固定this的值,然后内层函数调用这个变量,是非常常见的做法,请务必掌握。
JavaScript 提供了严格模式,也可以硬性避免这种问题。严格模式下,如果函数内部的this指向顶层对象,就会报错。
避免数组处理方法中的 this
数组的map和foreach方法,允许提供一个函数作为参数。这个函数内部不应该使用this。
foreach方法的回调函数中的this,其实是指向window对象,因此取不到o.v的值。
1 | 最主要的问题就是函数表达式的出现,此时变成了全局环境。 |
解决这个问题的一种方法,就是前面提到的,使用中间变量固定this。
另一种方法是将this当作foreach方法的第二个参数,固定它的运行环境。
避免回调函数中的 this
回调函数中的this往往会改变指向,最好避免使用。
绑定this的方法
this的动态切换,固然为 JavaScript 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript 提供了call、apply、bind这三个方法,来切换/固定this的指向。
Function.prototype.call()
函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。
call方法的参数,应该是一个对象。如果参数为空、null和undefined,则默认传入全局对象。
如果call方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入call方法。
call方法还可以接受多个参数。
call的第一个参数就是this所要指向的那个对象,后面的参数则是函数调用时所需的参数。
call方法的一个应用是调用对象的原生方法。
继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。
Function.prototype.apply()
apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。
apply方法的第一个参数也是this所要指向的那个对象,如果设为null或undefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加。
利用这一点,可以做一些有趣的应用。
找出数组最大元素
JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素。
将数组的空元素变为undefined
通过apply方法,利用Array构造函数将数组的空元素变成undefined。
空元素与undefined的差别在于,数组的forEach方法会跳过空元素,但是不会跳过undefined。因此,遍历内部元素的时候,会得到不同的结果。
转换类似数组的对象
另外,利用数组对象的slice方法,可以将一个类似数组的对象(比如arguments对象)转为真正的数组。
绑定回调函数的对象
Function.prototype.bind()
bind方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。
bind还可以接受更多的参数,将这些参数绑定原函数的参数。
bind还可以接受更多的参数,将这些参数绑定原函数的参数。
bind方法有一些使用注意点。
每一次返回一个新函数
bind方法每运行一次,就返回一个新函数,这会产生一些问题。
结合回调函数使用
回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含this的方法直接当作回调函数。
结合call方法使用
prototype 对象
面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。
大部分面向对象的编程语言,都是通过“类”(class)来实现对象的继承。JavaScript 语言的继承则是通过“原型对象”(prototype)。
原型对象概述
构造函数的缺点
通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。
每新建一个实例,就会新建一个meow方法。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。
这个问题的解决方法,就是 JavaScript 的原型对象(prototype)。
prototype 属性的作用
JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。
JavaScript 规定,每个函数都有一个prototype属性,指向一个对象。
对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为类类。1
类类是自创的概念,类似于类的东西,即指不管实例对象是否存在,它都客观存在的实例对象的原型。
原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。
当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。
如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。
原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。
原型链
JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……
如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOf和toString方法的原因,因为这是从Object.prototype继承的。
那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是null。null没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null。
读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。
注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。
constructor 属性
prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。
由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。
constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。
constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。
构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的contructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object。
修改原型对象时,一般要同时修改constructor属性的指向。
要么将constructor属性重新指向原来的构造函数,要么只在原型对象上添加方法。
如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。
instanceof 运算符
instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。
instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。
只要一个对象的原型不是null,instanceof运算符的判断就不会失真。
instanceof运算符的一个用处,是判断值的类型。
注意,instanceof运算符只能用于对象,不适用原始类型的值。
对于undefined和null,instanceOf运算符总是返回false。
利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。
Object 对象的相关方法
Object.getPrototypeOf()
Object.getPrototypeOf方法返回参数对象的原型。这是获取原型对象的标准方法。
Object.setPrototypeOf()
Object.setPrototypeOf方法为参数对象设置原型,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象。
new命令可以使用Object.setPrototypeOf方法模拟。
new命令新建实例对象,其实可以分成两步。第一步,将一个空对象的原型设为构造函数的prototype属性(上例是F.prototype);第二步,将构造函数内部的this绑定这个空对象,然后执行构造函数,使得定义在this上面的方法和属性(上例是this.foo),都转移到这个空对象上。
Object.create()
JavaScript 提供了Object.create方法,该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性。
下面三种方式生成的新对象是等价的。
如果想要生成一个不继承任何属性(比如没有toString和valueOf方法)的对象,可以将Object.create的参数设为null。
使用Object.create方法的时候,必须提供对象原型,即参数不能为空,或者不是对象,否则会报错。
Object.create方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。
除了对象的原型,Object.create方法还可以接受第二个参数。该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性。
Object.create方法生成的对象,继承了它的原型对象的构造函数。
Object.prototype.isPrototypeOf()
实例对象的isPrototypeOf方法,用来判断该对象是否为参数对象的原型。
只要实例对象处在参数对象的原型链上,isPrototypeOf方法都返回true。
Object.prototype.proto
实例对象的proto属性(前后各两个下划线),返回该对象的原型。该属性可读写。
根据语言标准,proto属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表明它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用Object.getPrototypeof()和Object.setPrototypeOf(),进行原型对象的读写操作。
获取原型对象方法的比较
获取实例对象obj的原型对象,有三种方法。
前两种都不是很可靠。proto属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效。
推荐使用第三种Object.getPrototypeOf方法,获取原型对象。
Object.getOwnPropertyNames()
Object.getOwnPropertyNames方法返回一个数组,成员是参数对象本身的所有属性的键名,不包含继承的属性键名。
Object.getOwnPropertyNames方法返回所有键名,不管是否可以遍历。只获取那些可以遍历的属性,使用Object.keys方法。
Object.prototype.hasOwnProperty()
对象实例的hasOwnProperty方法返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上。
注意,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法。
in 运算符和 for…in 循环
in运算符返回一个布尔值,表示一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性。
in运算符常用于检查一个属性是否存在。
获得对象的所有可遍历属性(不管是自身的还是继承的),可以使用for…in循环。
对象的拷贝
如果要拷贝一个对象,需要做到下面两件事情。
面向对象编程的模式
构造函数的继承
让一个构造函数继承另一个构造函数,是非常常见的需求。
这可以分成两步实现。第一步是在子类的构造函数中,调用父类的构造函数。
第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。
上面代码中,Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype一起修改掉。
1 | JavaScript 中对象的赋值是默认引用赋值的,如果你想要复制赋值,则必须要重新分配对象 |
另外一种写法是Sub.prototype等于一个父类实例。
上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。
有时只需要单个方法的继承,这时可以采用下面的写法。
上面代码中,子类B的print方法先调用父类A的print方法,再部署自己的代码。这就等于继承了父类A的print方法。
多重继承
JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。
这种模式又称为 Mixin(混入)。
模块
JavaScript不是一种模块化编程语言,ES5不支持”类”(class),更遑论”模块”(module)了。ES6正式支持”类”和”模块”,但还没有成为主流。
基本的实现方法
模块是实现特定功能的一组属性和方法的封装。
只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。
这种做法的缺点很明显:”污染”了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。
为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。
上面的函数m1和m2,都封装在module1对象里。使用的时候,就是调用这个对象的属性。
但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。
封装私有变量:构造函数的写法
我们可以利用构造函数,封装私有变量。
这种方法将私有变量封装在构造函数中,违反了构造函数与实例对象相分离的原则。并且,非常耗费内存。
封装私有变量:立即执行函数的写法
使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的。
上面的module1就是JavaScript模块的基本写法。下面,再对这种写法进行加工。
模块的放大模式
如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation)。
上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。
在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上面的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用”宽放大模式”(Loose augmentation)。
与”放大模式”相比,“宽放大模式”就是“立即执行函数”的参数可以是空对象。
输入全局变量
独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。
为了在模块内部调用全局变量,必须显式地将其他变量输入模块。
上面的module1模块需要使用jQuery库和YUI库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。